iT邦幫忙

2021 iThome 鐵人賽

DAY 9
1

本節是以 Golang 上游 ee91bb83198f61aa8f26c3100ca7558d302c0a98 為基準做的實驗。

予焦啦!昨日不幸地卡在一個弔詭之處:整支程式的初始化就是一行一行照著走,但為什麼存在記憶體內的資料卻不能正常使用呢?沒錯,有個關鍵的初始化我們還沒有做的,那就是 BSS 區段的相關處理

本節重點概念

  • 基礎
    • readelf 工具查詢區段與符號
    • .bss 區段
  • Golang
    • 寫入屏障(Write Barrier)機制概述

.bss:未初始化資料區

昨日,最令我們困惑的部分是,一開始存在的內容似乎就已經註定讓我們走向錯誤,但事實上有個盲點:那些資料存在記憶體內,但它們未必是應該存在的。

我們可以使用 readelf 工具再看看 g0m0 這兩個東西:

$ riscv64-buildroot-linux-musl-readelf -s ethanol/ethanol
...
  1004: ffffff80000aa310    16 OBJECT  GLOBAL DEFAULT   10 runtime.modinfo
  1005: ffffff80000ac040   976 OBJECT  GLOBAL DEFAULT   11 runtime.m0
  1006: ffffff80000abea0   392 OBJECT  GLOBAL DEFAULT   11 runtime.g0
  1007: ffffff80000d6770     8 OBJECT  GLOBAL DEFAULT   12 runtime.mcache0
...

實際上,這兩個符號所代表的結構處在同一個、我們未曾探討過的區段。使用 readelf 工具觀察區段資訊:

$ riscv64-buildroot-linux-musl-readelf -S ethanol/ethanol
...
Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
...
  [ 9] .noptrdata        PROGBITS         ffffff80000a9020  000a8020
       00000000000012a0  0000000000000000  WA       0     0     32
  [10] .data             PROGBITS         ffffff80000aa2c0  000a92c0
       0000000000001990  0000000000000000  WA       0     0     32
  [11] .bss              NOBITS           ffffff80000abc60  000aac60
       000000000002aa08  0000000000000000  WA       0     0     32
  [12] .noptrbss         NOBITS           ffffff80000d6680  000d5680
       0000000000004c00  0000000000000000  WA       0     0     32
...

每個區段都標示了所屬的位址(Address 欄位)。所以我們可以對照出,g0 所屬的 0xffffff80000abea0 其實位在 .bss 區段,因為 .bss 區段從 0xffffff80000abc60 開始,且大小為 0x2aa08Size 欄位),完全涵蓋 g0 的位址。事實上,它也完全涵蓋 m00xffffff80000ac040

關於 .bss 區段的說明,可以參考維基

在執行檔或是物件檔中,.bss 區段用來存放未賦予初值的資料。g0m0 在 Golang 程式碼中都沒有被賦予初值,所以歸屬在這個區段裡面。這個區段的類型(Type 欄位)被標記為 NOBITS,意味著,這個區段的內容可以在動態執行時期才配置,相反的,這個區段在檔案當中可以不存在。

一般而言 C 語言程式的產物的 .bss 在 ELF 檔案中完全不會佔據空間,但是 Golang 程式還是會佔據那些空間,所以從 readelf 的 偏移量(offset)欄位還是可以發現到變化。

無論如何,既然 g0m0 是未初始值,而 Golang 的慣例又是未初始變數一定有 0 值,那麼我們就必須將之清除為 0 了。這有三個層面,

  1. 產物因素:Golang 的 go build 指令的產物(ethanol/ethanol 執行檔)中,相對應於 .bss 的偏移的部分是否含有內容?
  2. 載入器(loader)因素:QEMU 使用的 -device loader ... 參數項目實際上如何載入整個 ethanol 映像檔?
  3. 執行期因素:執行期初始化階段是否應處理 .bss

產物因素

其實,.bss 或是 .noptrbss 在 Golang 執行檔中都不佔據實體空間,這可以從幾個角度驗證。第一個是 readelf 工具給予我們的區段資訊:

  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
...
  [11] .bss              NOBITS           ffffff80000abc60  000aac60
       000000000002aa08  0000000000000000  WA       0     0     32
  [12] .noptrbss         NOBITS           ffffff80000d6680  000d5680
       0000000000004c00  0000000000000000  WA       0     0     32
...

類型欄位(Type)中的 NOBITS 值就代表,這些區段在檔案中沒有實質內容。第二個是載入時的區段:

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0xffffff8000001040 0xffffff8000001040
                 0x0000000000000118 0x0000000000000118  R      0x1000
  NOTE           0x0000000000000f9c 0xffffff8000001f9c 0xffffff8000001f9c
                 0x0000000000000064 0x0000000000000064  R      0x4
  LOAD           0x0000000000000000 0xffffff8000001000 0xffffff8000001000
                 0x0000000000055104 0x0000000000055104  R E    0x1000
  LOAD           0x0000000000056000 0xffffff8000057000 0xffffff8000057000
                 0x0000000000051ef8 0x0000000000051ef8  R      0x1000
  LOAD           0x00000000000a8000 0xffffff80000a9000 0xffffff80000a9000
                 0x0000000000002c60 0x0000000000032280  RW     0x1000
                 
 Section to Segment mapping:
  Segment Sections...
   00     
   01     .note.go.buildid 
   02     .text .note.go.buildid 
   03     .rodata .typelink .itablink .gosymtab .gopclntab 
   04     .go.buildinfo .noptrdata .data .bss .noptrbss

這裡可以看到 .bss.noptrbss 對應到第五個區塊(segment),而第五個區塊也是唯一一個檔案中大小(欄位 FileSiz)與記憶體大小(欄位 MemSiz)不一樣的。

又,也可以使用 objdump 工具觀察

$ riscv64-buildroot-linux-musl-objdump -h hw
...
hw:     file format elf64-littleriscv

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
...
  8 .data         00001990  ffffff80000aa2c0  ffffff80000aa2c0  000a92c0  2**5
                  CONTENTS, ALLOC, LOAD, DATA
  9 .bss          0002aa08  ffffff80000abc60  ffffff80000abc60  000aac60  2**5
                  ALLOC
 10 .noptrbss     00004c00  ffffff80000d6680  ffffff80000d6680  000d5680  2**5
                  ALLOC
...

.data 區段做對照,則可發現 .bss.noptrbss 都具有特殊的 ALLOC 屬性。

載入器因素

QEMU 的 loader 支援三種主要功能,這裡有精簡的描述。

我們在做實驗時主要採用 QEMU 的通用載入器(generic loader)來將檔案或資料放置到指定的位址。由於我們在載入 ethanol 映像檔的時候強迫開啟了原始格式(force-raw = true),所以是整個檔案載入。若是沒有開啟原始格式,則這個檔案會被當作 ELF 檔載入。

如前一小節的敘述,雖然檔案內沒有實質對應到 .bssnoptrbss 的內容,但是現在這個原始格式的載入器,會按照檔案位置如實地載入。所以我們可以回顧當初存取 m0 內容的時候,當時是存取 0x802ac0e0 這個位址,也就是以檔案內偏移量來算的 0xab0e0 的位置,實際上它會對應到:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [11] .bss              NOBITS           ffffff80000abc60  000aac60
       000000000002aa08  0000000000000000  WA       0     0     32
  [12] .noptrbss         NOBITS           ffffff80000d6680  000d5680
       0000000000004c00  0000000000000000  WA       0     0     32
  [13] .zdebug_abbrev    PROGBITS         ffffff80000dc000  000ab000
       0000000000000119  0000000000000000           0     0     1
  [14] .zdebug_line      PROGBITS         ffffff80000dc119  000ab119
       000000000000e7e2  0000000000000000           0     0     1
...

.zdebug_abbrev 區段裡面,且使用 hexdump 工具檢驗看看:

$ hexdump -C hw
...
000ab0e0  59 1f d6 e0 5b e5 e0 74  28 46 bf 0b 67 a5 44 a3  |Y...[..t(F..g.D.|
...

正是我們昨日觀察到的,錯誤記憶體存取的基底!總之是一個不該被拿來當作記憶體位址的誇張奇異值。

所以也許我們別開啟原始格式就足夠了?但下一節才是最踏實的方案。

執行期因素

以其他系統軟體為例,比方說前文中提過的 LinuxOpenSBI,都會在相當早期的階段清除 BSS 的內容。我們也應該這麼做:

diff --git a/src/runtime/rt0_opensbi_riscv64.s b/src/runtime/rt0_opensbi_riscv64.s
index c65afa5c79..abaf4ba280 100644
--- a/src/runtime/rt0_opensbi_riscv64.s
+++ b/src/runtime/rt0_opensbi_riscv64.s
@@ -72,5 +72,12 @@ TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
        JMP     main(SB)
 
 TEXT main(SB),NOSPLIT|NOFRAME,$0
+       MOV     $runtime bss(SB), T0
+       MOV     $runtime enoptrbss(SB), T1
+zeroize:
+       SD      ZERO, 0(T0)
+       ADD     $8, T0, T0
+       BLT     T0, T1, zeroize
+
        MOV     $runtime rt0_go(SB), T0

重新執行的話,得到

...
Boot HART MIDELEG         : 0x0000000000000222
Boot HART MEDELEG         : 0x000000000000a109
HI0000000000000005
ffffff8000075d00
0000000080245ae8     
QEMU: Terminated

仍然一樣撞到記憶體存取錯誤,但卡在不同地方了。觸發錯誤的位置在

ffffff8000045ac0 <runtime.moduledataverify1>:
ffffff8000045ac0:       010db503                ld      a0,16(s11)
ffffff8000045ac4:       fb810593                addi    a1,sp,-72
ffffff8000045ac8:       00b56863                bltu    a0,a1,ffffff8000045ad8 <runtime.moduleda
taverify1+0x18>
ffffff8000045acc:       0000df97                auipc   t6,0xd
ffffff8000045ad0:       b84f82e7                jalr    t0,-1148(t6) # ffffff8000052650 <runtime
.morestack_noctxt>
ffffff8000045ad4:       fedff06f                j       ffffff8000045ac0 <runtime.moduledataveri
fy1>
ffffff8000045ad8:       f2113c23                sd      ra,-200(sp)
ffffff8000045adc:       f3810113                addi    sp,sp,-200
ffffff8000045ae0:       0d013183                ld      gp,208(sp)
ffffff8000045ae4:       0001b383                ld      t2,0(gp)
ffffff8000045ae8:       0003e403                lwu     s0,0(t2)

而錯誤正是發生在最後這一行的 t2 暫存器中存放了 ffffff8000075d00 數值。看起來有點眼熟,對吧?是的,它不只是長得像而已,它實際上就是一個當前 ethanol 映像檔中的一個虛擬位址,而且是一個區段的開頭:

$ riscv64-buildroot-linux-musl-readelf -a hw
...
Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
...
  [ 7] .gopclntab        PROGBITS         ffffff8000075d00  00074d00
       00000000000331f8  0000000000000000   A       0     0     32
...
Symbol table '.symtab' contains 1089 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
...
   118: ffffff8000075ce8     8 OBJECT  LOCAL  DEFAULT    5 runtime.itablink
   119: ffffff8000075d00     0 OBJECT  LOCAL  DEFAULT    7 runtime.pclntab
   120: ffffff8000075240  1686 OBJECT  LOCAL  DEFAULT    2 runtime.findfunctab
...

相關的程式碼位在 src/runtime/symbol.go 當中,

func moduledataverify1(datap *moduledata) {                                                             // Check that the pclntab's format is valid.                                            
        hdr := datap.pcHeader                                                                   
        if hdr.magic != 0xfffffffa || hdr.pad1 != 0 || hdr.pad2 != 0 || hdr.minLC != sys.PCQuantum || hdr.ptrSize != sys.PtrSize {
...

回顧一下反組譯的結果。首先是堆疊指標(sp)偏移 0x208 之後的值存在 gp 暫存器,接著以 gp 暫存器作為基底取值存在 t2,然後最後 t2 的內容是我們還不能使用的虛擬位址指標,所以出錯。

這個行為對應到傳入參數 datap,這相當於是 gp 的值;datap.pcHeader 這一項取得成員的動作就相當於是 t2,之後的 hdr.magic 就是事故現場。那麼,為什麼最一開始會有那樣的值存入呢?

var firstmoduledata moduledata  // linker symbol 
var lastmoduledatap *moduledata // linker symbol
var modulesSlice *[]*moduledata // see activeModules
...
func moduledataverify() {
    for datap := &firstmoduledata; datap != nil; datap = datap.next {
            moduledataverify1(datap)
    } 
}

可見,源頭的 firstmoduledata 符號本身,是連結器在連結時期已經綁定到虛擬位址的符號;這個定址方式是絕對的,直接指定為 0xffffff8000075d00。相較於我們從一開始進入 ethanol/ethanol 到出現錯誤,期間經歷許多變數存取與函數呼叫,之所以不會遇到一樣的問題,是因為目前為止遭遇的符號都使用了相對程式指標定址(PC-related addressing),所以就算現在我們是被載入在實體的物理位址之上,也還是能夠正確地在執行時期存取到對的函數或是變數。

也就是說,firstmoduledata 裡面的內容,必須直接用到連結時就規定好的絕對位址;以 ethanol 核心的角度來講,是一個還沒有啟用的虛擬位址。連結器對於執行期會被載入到哪裡去,當然是沒有概念的,所以這裡我們遇到了一道更高的牆了。

是否已經到了啟用虛擬記憶體的時機了呢?這邊我們暫且打住,回頭檢視本日最一開始的問題。

寫入屏障(Write Barrier)概述

也許諸位讀者還是會感到疑惑:原先是因為讀取到髒值而導致存取錯誤,那麼為什麼將髒值清成零能夠解決問題呢?難道不會因為存取 0 作為指標而產生一樣的錯誤嗎

這就必須正視原本這個出問題的部分的寫入屏障機制。我們回顧昨日展示的執行追蹤:

(gdb) bt
#0  0x0000000080254510 in runtime.gcWriteBarrier ()
    at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:653
#1  0x000000008023ebd8 in runtime.args (c=-2145037200, v=0x82200000)
    at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/runtime1.go:63
#2  0x00000000802524b4 in runtime.rt0_go ()
    at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:54

可知是由 args 函式呼叫 gcWriteBarrier 函式,然而若我們查看原始碼,會發現

func args(c int32, v **byte) { 
    argc = c 
    argv = v 
    sysargs(c, v)                                   
}

根本就沒有這個呼叫!這完全是 Golang 編譯器擅作主張插入的內容,我們可以檢視反組譯的結果:

ffffff800003eba0:       00098f97                auipc   t6,0x98
ffffff800003eba4:       ae3fa823                sw      gp,-1296(t6) # ffffff80000d6690 <runtime
.argc>
ffffff800003eba8:       00098197                auipc   gp,0x98
ffffff800003ebac:       c881e183                lwu     gp,-888(gp) # ffffff80000d6830 <runtime.writeBarrier>
ffffff800003ebb0:       00019a63                bnez    gp,ffffff800003ebc4 <runtime.args+0x44>
ffffff800003ebb4:       01813183                ld      gp,24(sp)
ffffff800003ebb8:       0006df97                auipc   t6,0x6d
ffffff800003ebbc:       0c3fb823                sd      gp,208(t6) # ffffff80000abc88 <runtime.argv>
ffffff800003ebc0:       0180006f                j       ffffff800003ebd8 <runtime.args+0x58>
ffffff800003ebc4:       0006d297                auipc   t0,0x6d
ffffff800003ebc8:       0c428293                addi    t0,t0,196 # ffffff80000abc88 <runtime.argv>
ffffff800003ebcc:       01813303                ld      t1,24(sp)
ffffff800003ebd0:       00016f97                auipc   t6,0x16
ffffff800003ebd4:       920f80e7                jalr    -1760(t6) # ffffff80000544f0 <runtime.gcWriteBarrier>
ffffff800003ebd8:       00013083                ld      ra,0(sp)
ffffff800003ebdc:       00810113                addi    sp,sp,8
ffffff800003ebe0:       00008067                ret

關鍵在於 ffffff800003ebac 基於 WriteBarrier 的判斷。這裡組語指令是一個載入 4 個位元組的無號整數(lwu)並判斷它是否為零(bnez),其實是因為對應到這個位址的內容代表的是垃圾回收機制的寫入屏障是否啟動(enabled)。若是已經啓動,才去執行之前發生問題的 gcWriteBarrier

也就是說我們可以總結原本遭遇的錯誤了。這個用來判斷的旗標,其實本身也是處在 .bss 區段中的變數,由於未清除的緣故,觸發了還不應該使用的 Golang 垃圾回收機制。觸發了之後,沿著寫入屏障函數一路深入,最後終於存取到了未清除初值的結構體,因此引發錯誤。

整個 Golang 執行期提供如此豐富的功能給一般的使用者程式,我們怎麼能夠期待現在垃圾回收以及其他機制已經能夠提供我們使用了呢?

Golang 的垃圾回收機制從編譯(compile)與連結(link)時期就開始佈局了,所以最終的產物裡面,會像這樣看到寫入屏障的安插。當記憶體的改動累積到一定程度之後,Golang 的執行期必須要抽空執行垃圾回收演算法,以回收被棄用的指標或空間等等。我們目前還沒有必要深入這個部分,但可以先理解到這個機制的存在。

小結

予焦啦!今日我們做了一個小幅修正,確保 .bss 等未初始化的資料區段能夠正確的被初始化。我們又推進了一小步,但也馬上就又卡住了,而且這次的起因是,連結器符號所代表的結構體當中存放了虛擬記憶體位址。

於是,本章也應該要宣告結束了。筆者目前為止已經展示了基本的除錯技法、土法煉鋼也要往前爬的姿態以及暴虎馮河之心。無論如何,接下來我們也應該跨出新的步伐了,那就是:啟用虛擬記憶體。

說得更精煉一些:由於 Golang RISC-V 沒有提供建置位置非相依可執行檔(PIE,Position-Independent Executable)的選項,所以遭遇到絕對定址之後,我們就束手無策了。程式不可能按照原本規劃的那樣進行下去,因為目前為止都還是靠著程式指標相對定址搭配載入時期載入在實體物理位址的條件下執行的。為了解決這個現狀,也是時候該啟用虛擬記憶體了。

各位讀者,我們明日再會!


上一篇
予焦啦!使用 GDB 推進
下一篇
予焦啦!RISC-V 虛擬記憶體機制簡說
系列文
予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言